Skip to content

feat(react): createPortal via nodesRef patch ops#2543

Merged
upupming merged 16 commits into
mainfrom
feat-react-portal-patch-channel
May 8, 2026
Merged

feat(react): createPortal via nodesRef patch ops#2543
upupming merged 16 commits into
mainfrom
feat-react-portal-patch-channel

Conversation

@upupming
Copy link
Copy Markdown
Collaborator

@upupming upupming commented Apr 29, 2026

Summary

Adds createPortal to @lynx-js/react. Renders a vnode subtree into a different ReactLynx element identified by a NodesRef (from ref={setX} or lynx.createSelectorQuery()), with no compile-time marker required and arbitrary host structure permitted.

function App() {
  const [host, setHost] = useState(null);
  return (
    <view>
      <view ref={setHost} />
      {host && createPortal(<text>hi</text>, host)}
    </view>
  );
}

Different design vs #2501

This is an alternative implementation to #2501; both target the same feature with substantially different architectures. Brief comparison:

#2501 this PR
Marker on host portal-container attr required none required
Compile-time work SWC plugin lifts host subtree into a separate snapshot no transform — pure runtime
Host can have children no (must be empty placeholder) yes
Patch protocol new "detached subtree" lifecycle ops two new ops on the existing LifecycleConstant.patchUpdate channel
Hydrate diff path bypassed (portal subtree is detached) reused — portal subtree replays through reconstructInstanceTree
First-screen direct render needs separate machinery future-compatible: portal ops sit in the same Snapshot abstraction the direct-render path already consumes

Concretely, this implementation routes everything through SnapshotInstance so no new lifecycle protocol is introduced; the trade-off is that pre-hydrate Portal mounts need an extra queue-and-replay step (pendingInsertBeforeclearPendingPortalInsertBefore) instead of being lifted by a transform.

Limitation

Currently NodesRef is a BTS type, we cannot get it on MTS. So IFR is not supported.

Implementation notes

  • New patch ops: nodesRefInsertBefore(identifier, childId, beforeId?) / nodesRefRemoveChild(identifier, childId). Carried via the existing patchUpdate channel alongside BSI CreateElement / InsertBefore / RemoveChild ops; no new protocol.
  • fakeRoot.insertBefore wires child.__parent = fakeRoot so preact's removeNode (which walks child.parentNode.removeChild(child)) routes through portal removeChild. Without this, unmount silently no-ops.
  • Pre-hydrate Portal mounts: BSI constructor's CreateElement push is dropped (global buffer is undefined), so fakeRoot.insertBefore queues into pendingInsertBefore. clearPendingPortalInsertBefore (called from hydrate()) replays the dropped subtree ops via reconstructInstanceTree([child]), then emits nodesRefInsertBefore to attach to host.
  • reconstructInstanceTree extracted to its own module (snapshot/reconstructInstanceTree.ts) so portal pre-hydrate replay can share the helper without forming an import cycle with backgroundSnapshot.ts.

Test coverage

Two test suites:

runtime/__test__/snapshot/lynx/portals.test.jsx — unit tests on the runtime path (10 cases):

  • createPortal returns a VNode whose containerInfo points at the host
  • pre-hydrate → hydrate → unmount full lifecycle
  • post-hydrate Portal mount via state change
  • container swap (covers _this._container !== container path)
  • multi-child reorder + prepend (covers before?.__id truthy branch + apply __InsertElementBefore)
  • context propagation across portal boundary (ContextProvider wrapper)
  • serializeNodesRef non-RefProxy path
  • nodesRefInsertBefore / nodesRefRemoveChild ctx-not-found soft-fail
  • selector miss no-op

testing-library/src/__tests__/portals.test.jsx — high-level scenarios mirroring PR #2501's portal.test.jsx (16 cases): basic render, re-render on portalled state change, context forwarding, event-bubbling semantics across portal boundary, third-party-slot pattern, mount/unmount toggle, cleanup on unmount, host swap.

Runtime test env (__test__/snapshot/utils/nativeMethod.ts) gets __GetPageElement + __QuerySelector mocks ([attr] / [attr=value] selectors).

Coverage: 100% lines / 100% branches / 100% functions / 100% statements on the runtime suite.

Test plan

  • pnpm -F @lynx-js/react-runtime test — 570 passing, 100% coverage
  • pnpm -F @lynx-js/reactlynx-testing-library test:base -- src/__tests__/portals.test.jsx — 16 passing
  • hand-review by owners on which design (feat(react): add createPortal support #2501 or this) better fits the long-term path

Summary by CodeRabbit

  • New Features

    • Added createPortal() to render subtrees into alternate element targets via node-reference selectors.
  • Tests

    • Expanded portal test coverage: hydration, mount/hydrate/unmount flows, container swapping, insertion ordering, context propagation, selector-targeted portals, and teardown/regressions.
  • Improvements

    • Improved portal hydration replay and snapshot patch handling; enhanced node-reference resolution and testing-environment query helpers.

@upupming upupming requested review from HuJean, Yradex and hzy as code owners April 29, 2026 06:46
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 29, 2026

🦋 Changeset detected

Latest commit: 9821d16

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@lynx-js/react Patch
@lynx-js/react-umd Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@upupming upupming marked this pull request as draft April 29, 2026 06:46
@upupming upupming force-pushed the feat-react-portal-patch-channel branch from 67fc30b to 94f9f0b Compare April 29, 2026 06:49
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds createPortal support (public API and runtime exports), NodesRef serialization and RefProxy selector, two new snapshot operations with main-thread appliers, a pre-hydration pending queue and replay, an instance-tree reconstruction helper, background hydrate integration, and extensive snapshot and RTL tests.

Changes

Portal Rendering and Patch Operations

Layer / File(s) Summary
Data Shape & API
packages/react/etc/react.api.md, packages/react/types/react.docs.d.ts, packages/react/runtime/types/types.d.ts, .changeset/*
Adds createPortal declaration, JSDoc re-export, ambient __QuerySelector type, and changeset example.
Snapshot Op Codes
packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatch.ts
Adds nodesRefInsertBefore and nodesRefRemoveChild opcodes and params; updates debug formatter test.
NodesRef Serialization
packages/react/runtime/src/snapshot/lynx/nodesRef.ts, packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts
Implements serializeNodesRef, defines NodeSelectType/NodeSelectToken, and adds RefProxy.selector getter.
Core Portal Implementation
packages/react/runtime/src/snapshot/lynx/portals.ts
Implements createPortal(vnode: ComponentChild, container: NodesRef) with fake-root mechanism, ContextProvider, and lifecycle/patch emission.
Portal Pre-Hydration Queue
packages/react/runtime/src/snapshot/lynx/portalsPending.ts
Exports pendingInsertBefore queue and clearPendingPortalInsertBefore() to replay queued portal operations after hydration.
Patch Application
packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts
Adds resolveNodesRefHost, applyNodesRefInsertBefore, and applyNodesRefRemoveChild for applying nodesRef ops on the main thread.
Patch Handler Integration
packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatchApply.ts
Adds switch cases delegating nodesRefInsertBefore and nodesRefRemoveChild to appliers.
Instance Tree Reconstruction
packages/react/runtime/src/snapshot/snapshot/reconstructInstanceTree.ts
New helper to DFS-walk BackgroundSnapshotInstance subtrees and emit CreateElement/SetAttributes/InsertBefore ops.
Background Hydration Flow
packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
Imports reconstructInstanceTree and calls clearPendingPortalInsertBefore() during hydrate finalization.
Ref Proxy Selector
packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts
Adds selector getter on RefProxy used by exec.
Type Exports
packages/react/runtime/src/index.ts
Imports and exports createPortal in default and named exports.
React Compat Exports
packages/react/runtime/lazy/react.js, packages/react/runtime/lazy/compat.js
Adds createPortal to re-exported React-compatible members.
Changelog
.changeset/feat-react-portal-patch-channel.md
Patch changeset and example usage demonstrating createPortal with a host ref.

Testing Infrastructure and Helpers

Layer / File(s) Summary
Snapshot Tests
packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx
Comprehensive snapshot test suite covering portal lifecycles, queued pre-hydration inserts, container swaps, keyed portals, context forwarding, serializeNodesRef behavior, and nodesRef op apply cases.
Test Utilities
packages/react/runtime/__test__/snapshot/utils/nativeMethod.ts
Adds __GetPageElement() and __QuerySelector() helpers for test element queries.
Snapshot Patch Formatting
packages/react/runtime/__test__/snapshot/debug/formatPatch.test.ts
Updates expected pretty-format output to include nodesRefInsertBefore and nodesRefRemoveChild.
Testing Library Portals
packages/react/testing-library/src/__tests__/portals.test.jsx
Large RTL suite for createPortal patterns, cleanup ordering, list-item reuse, and selector-query portals.
List Tests Spy Target
packages/react/testing-library/src/__tests__/list.test.jsx
Spies updated to target lynxTestingEnv.mainThread.globalThis.
Fire Event NodesRef
packages/react/testing-library/src/fire-event.ts
Expands element resolution for NodesRef to support ID_SELECTOR (CSS selector) and UNIQUE_ID lookup.
Testing Environment API
packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts, packages/testing-library/testing-environment/etc/testing-environment.api.md
Adds __GetPageElement() and __QuerySelector() to ElementTree/PAPI and wraps some list PAPI calls with main-thread switching.
NodesRef Support
packages/testing-library/testing-environment/src/index.ts
Updates setNativeProps and createSelectorQuery.select to store selector identifiers and resolve tokens by type.
Playground
packages/genui/a2ui-playground/lynx-src/App.tsx
Small UI state additions for loading/error handling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • Yradex
  • hzy

Poem

🐰 A portal opens, wide and bright,
I hop through code and hold it light.
I queue the patches, then I play—
Hydrate, replay, then fade away.
🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the primary change: adding createPortal functionality via new nodesRef patch operations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-react-portal-patch-channel

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 67fc30b68a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/runtime/src/snapshot/lynx/portals.ts
Comment thread packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatchApply.ts Outdated
Comment thread packages/react/runtime/src/snapshot/lynx/nodesRef.ts
@upupming upupming force-pushed the feat-react-portal-patch-channel branch 3 times, most recently from b424bce to 1565409 Compare April 29, 2026 06:56
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 29, 2026

Codecov Report

❌ Patch coverage is 95.52716% with 14 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...ibrary/testing-environment/src/lynx/ElementPAPI.ts 56.66% 13 Missing ⚠️
...s/testing-library/testing-environment/src/index.ts 90.90% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@upupming upupming force-pushed the feat-react-portal-patch-channel branch 2 times, most recently from aa72c8d to 2e85cbe Compare April 29, 2026 07:51
@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 29, 2026

React Example

#7883 Bundle Size — 235.77KiB (+0.66%).

9821d16(current) vs 0d51ee8 main#7874(baseline)

Bundle metrics  Change 4 changes Regression 1 regression
                 Current
#7883
     Baseline
#7874
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 37.77% 0%
No change  Chunks 0 0
No change  Assets 4 4
Change  Modules 197(+4.79%) 188
Regression  Duplicate Modules 80(+5.26%) 76
Change  Duplicate Code 44.85%(-0.51%) 45.08%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#7883
     Baseline
#7874
No change  IMG 145.76KiB 145.76KiB
Regression  Other 90.01KiB (+1.76%) 88.46KiB

Bundle analysis reportBranch feat-react-portal-patch-channelProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 29, 2026

React MTF Example

#1013 Bundle Size — 206.69KiB (+0.76%).

9821d16(current) vs 0d51ee8 main#1004(baseline)

Bundle metrics  Change 4 changes Regression 1 regression
                 Current
#1013
     Baseline
#1004
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 45.78% 0%
No change  Chunks 0 0
No change  Assets 3 3
Change  Modules 192(+4.92%) 183
Regression  Duplicate Modules 77(+5.48%) 73
Change  Duplicate Code 44.36%(-0.54%) 44.6%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#1013
     Baseline
#1004
No change  IMG 111.23KiB 111.23KiB
Regression  Other 95.46KiB (+1.65%) 93.91KiB

Bundle analysis reportBranch feat-react-portal-patch-channelProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 29, 2026

React External

#998 Bundle Size — 690.27KiB (+0.67%).

9821d16(current) vs 0d51ee8 main#989(baseline)

Bundle metrics  Change 1 change
                 Current
#998
     Baseline
#989
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 40.17% 0%
No change  Chunks 0 0
No change  Assets 3 3
No change  Modules 17 17
No change  Duplicate Modules 5 5
No change  Duplicate Code 8.59% 8.59%
No change  Packages 0 0
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#998
     Baseline
#989
Regression  Other 690.27KiB (+0.67%) 685.69KiB

Bundle analysis reportBranch feat-react-portal-patch-channelProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 29, 2026

Web Explorer

#9455 Bundle Size — 900.02KiB (0%).

9821d16(current) vs 0d51ee8 main#9446(baseline)

Bundle metrics  Change 1 change
                 Current
#9455
     Baseline
#9446
No change  Initial JS 44.46KiB 44.46KiB
No change  Initial CSS 2.22KiB 2.22KiB
No change  Cache Invalidation 0% 0%
No change  Chunks 9 9
No change  Assets 11 11
Change  Modules 229(-0.43%) 230
No change  Duplicate Modules 11 11
No change  Duplicate Code 27.28% 27.28%
No change  Packages 10 10
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#9455
     Baseline
#9446
No change  JS 495.88KiB 495.88KiB
No change  Other 401.92KiB 401.92KiB
No change  CSS 2.22KiB 2.22KiB

Bundle analysis reportBranch feat-react-portal-patch-channelProject dashboard


Generated by RelativeCIDocumentationReport issue

@upupming upupming force-pushed the feat-react-portal-patch-channel branch 3 times, most recently from d9ff5c1 to 6bbc286 Compare April 29, 2026 10:01
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:

- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
  via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
  CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
  `removeNode` (which walks `child.parentNode.removeChild`) routes through
  portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
  `clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
  subtree's dropped CreateElement / SetAttributes / internal InsertBefore
  ops via `reconstructInstanceTree`, then attaches the subtree to host
  via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
  pre-hydrate replay can share the helper without forming an import
  cycle with `backgroundSnapshot.ts`.

Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.

Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks. testing-library suite includes a preact-parity
case ported from internal-preact's `feat/portal-slot` branch verifying
that portal content stays put while host's normal children toggle.
@upupming upupming force-pushed the feat-react-portal-patch-channel branch from 6bbc286 to ffc0600 Compare April 29, 2026 10:08
`nodesRefInsertBefore` / `nodesRefRemoveChild` previously soft-failed
when the selector didn't resolve or the BSI subtree wasn't materialized.
That's a caller bug (stale `NodesRef`), so use non-null assertions and
throw instead of silently dropping the op.
Comment thread packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx Outdated
Comment thread packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx
Comment thread packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx Outdated
Comment thread packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatchApply.ts Outdated
Comment thread packages/react/runtime/src/snapshot/lynx/portals.ts
- Throw meaningful errors (not raw `TypeError`s) when `nodesRefInsertBefore`
  / `nodesRefRemoveChild` apply hits a caller-bug state — message names
  the op, the childId, the selector, and hints at the most likely cause.
- Move the portal-only apply handlers + selector lookup into a new
  `nodesRefApply.ts` module so `snapshotPatchApply.ts` stays focused on
  snapshot-tree ops.
- Reject non-CSS-selector `NodesRef`s (`selectReactRef`, `selectUniqueID`)
  in `serializeNodesRef` instead of silently no-oping on the main thread.
- Drain matching pending inserts from `pendingInsertBefore` when a portal
  child is unmounted before hydrate, so the queue replay during hydrate
  doesn't resurrect a child that was already torn down on background.
- Switch the container-swap test to assert the portaled subtree actually
  moves from container A to container B.
- Use `toThrowErrorMatchingInlineSnapshot` for the throw-path tests.
HuJean pushed a commit that referenced this pull request Apr 30, 2026
## Summary

[#2538](#2538) dropped
`//#build` from `build.dependsOn` to reduce cache fanout. `//#build`
runs the root `tsc --build`, which produces the composite-project `lib/`
outputs that several workspace packages declare as their public types:

- `@lynx-js/rspeedy` → `"types": "./lib/index.d.ts"`
- `@lynx-js/template-webpack-plugin`,
`@lynx-js/web-rsbuild-server-middleware`, etc.

`//#build` still runs as part of `pnpm turbo build` (no filter), but now
in parallel with package builds. Two packages whose `tsgo` dts
generation imports types from those `lib/` outputs race against
`//#build`:

- `@lynx-js/config-rsbuild-plugin` (imports `@lynx-js/rspeedy`,
`@lynx-js/template-webpack-plugin` in `LynxConfigWebpackPlugin.ts` /
`pluginLynxConfig.ts`)
- `@lynx-js/reactlynx-testing-library` (imports `@lynx-js/rspeedy` in
`rstest-config.ts`)

When the race is lost, those builds fail with `TS2307: Cannot find
module …`. This was hit on PR #2543 ([build / Build
(Ubuntu)](https://github.com/lynx-family/lynx-stack/actions/runs/25103126514/job/73557596812)
— same failure on Windows).

## Fix

Add `//#build` to the `dependsOn` of just the two affected packages (not
to the global `build.dependsOn`) so they wait for `tsc --build` without
re-introducing the cache fanout #2538 was avoiding.

## Test plan

- [x] ```bash
  find packages -name "*.tsbuildinfo" -delete
  find packages -type d -name lib -exec rm -rf {} +
  pnpm turbo build --force
  ```
succeeds end-to-end (49/49 tasks). On `main` (without this fix) the same
command fails on `@lynx-js/config-rsbuild-plugin#build` and
`@lynx-js/reactlynx-testing-library#build`.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Resolved TypeScript type resolution failures in clean CI/build
environments.

* **Chores**
* Ensured root composite build runs before package builds to enforce
correct build ordering and artifact availability.
* Added build configuration for a new extractor package and updated
package build dependencies.

* **Documentation**
* Clarified maintenance guidance for produced/generated build artifacts
and cache outputs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Adds two cross-component tests in `react/testing-library` that drive the
portal end-to-end via `lynx.createSelectorQuery().select('#id')` instead
of a React ref — covers the CSS-selector `NodesRef` apply path that
real apps hit when the host and the consumer don't share a ref tree.

Also fixes the testing-library mock to match real Lynx behavior:
`select(selector)` now stores the CSS selector string in
`_nodeSelectToken.identifier` (instead of pre-resolving to the element's
unique id). `setNativeProps` and `fireEvent`'s `NodesRef` branch resolve
ID_SELECTOR tokens via `document.querySelector` and UNIQUE_ID tokens
via `__GetElementByUniqueId`, keeping existing tests passing.
`createPortal` is only invoked from the background thread — short-circuit
to `null` on main thread so preact's `render`/`createElement` and the BSI
linkage helpers don't get pulled into the main-thread chunk.

Fixes a typo (`if (__MAIN_THREAD__) null;` was a discarded expression that
left the function falling through), updates the public api-extractor
baseline (`VNode<any>` → `VNode<any> | null`), and updates the runtime
test to assert the early-return on main thread + materialization on
background thread.

Also adds `nodesRefInsertBefore` / `nodesRefRemoveChild` to the
`formatPatch.test.ts` fixture so the params-metadata for those entries
is regression-checked.
Drops the api-extractor `ae-missing-release-tag` warning by tagging
`createPortal` `@public`, matching how it's already exposed via
`react.docs.d.ts`. Refreshes the api-extractor baseline accordingly.
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 30, 2026

Merging this PR will improve performance by 19.23%

⚡ 3 improved benchmarks
✅ 78 untouched benchmarks
⏩ 26 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
008-many-use-state-destroyBackground 9.5 ms 8 ms +19.23%
transform 1000 view elements 46.9 ms 40.1 ms +16.97%
basic-performance-text-200 12.5 ms 11.4 ms +9.49%

Comparing feat-react-portal-patch-channel (9821d16) with main (0d51ee8)

Open in CodSpeed

Footnotes

  1. 26 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@upupming upupming marked this pull request as ready for review May 6, 2026 07:14
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2c5cbda4b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/testing-library/testing-environment/src/index.ts (1)

448-458: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't require the selector target to exist at select() time.

select() now throws if the element is not in the document yet, but this same file resolves selector-based NodesRefs later in setNativeProps().exec(). That makes the testing environment stricter than the modeled API and breaks valid cases where the target is created after the NodesRef is built.

💡 Suggested fix
-          const el = lynxTestingEnv.env.window.document.querySelector(
-            selector,
-          ) as LynxElement;
-          if (!el) {
-            throw new Error(
-              `[createSelectorQuery.select] No element matches selector: ${selector}`,
-            );
-          }
+          try {
+            lynxTestingEnv.env.window.document.querySelector(selector);
+          } catch {
+            throw new Error(
+              `[createSelectorQuery.select] Invalid selector: ${selector}`,
+            );
+          }
           return new NodesRef({}, {
             type: IdentifierType.ID_SELECTOR,
             identifier: selector,
           });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/testing-library/testing-environment/src/index.ts` around lines 448 -
458, createSelectorQuery.select is eagerly querying the document via
lynxTestingEnv.env.window.document.querySelector and throwing if no element
exists, but the runtime resolves selector-based NodesRef later in
setNativeProps().exec(); remove the eager existence check and instead store the
selector string (as a NodesRef) even if querySelector returns null, so
createSelectorQuery.select returns the selector-based reference without throwing
and setNativeProps().exec() continues to resolve the selector to a LynxElement
at execution time.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts`:
- Around line 63-70: The current logic silently falls back to __AppendElement
when beforeId is provided but unresolved; change it so that when beforeId !==
undefined and snapshotInstanceManager.values.get(beforeId) is missing or lacks
__element_root you treat this as an error instead of appending: locate the block
that checks beforeId, snapshotInstanceManager.values.get(beforeId), and calls
__InsertElementBefore or __AppendElement, and replace the silent-append behavior
with error handling (e.g., throw or processLogger.error + throw) that surfaces
the unresolved beforeId (include the beforeId and any relevant context like
host/childRoot) so stale patch bugs aren’t masked; only call __AppendElement
when beforeId === undefined.

In `@packages/react/runtime/src/snapshot/lynx/nodesRef.ts`:
- Around line 35-46: The code force-casts nodesRef to access _nodeSelectToken
and then reads nodeSelectToken.type unguarded; instead, first check that
(nodesRef as any)._nodeSelectToken exists and is an object (e.g. validate its
presence and that it has a type field) before dereferencing, and if not present
or invalid throw the same explicit Error used for unsupported NodeSelectType;
update the logic around the nodeSelectToken extraction in nodesRef handling
(symbols: nodesRef, _nodeSelectToken, NodeSelectToken, NodeSelectType,
createPortal) so malformed inputs produce the clear `[createPortal] unsupported
NodesRef type ...` error rather than an opaque TypeError.

In `@packages/react/runtime/src/snapshot/lynx/portals.ts`:
- Around line 106-131: The portal removeChild implementation only detaches the
node and cancels pendingInsertBefore but must mirror the full teardown performed
by BackgroundSnapshotInstance.removeChild: update the portal removeChild (the
function in portals.ts) to mark the removed subtree as removed, clear/null any
descendant snapshot refs, and enqueue the subtree's background snapshot id into
globalBackgroundSnapshotInstancesToRemove (using the same mechanism
backgroundSnapshotInstanceManager/BackgroundSnapshotInstance.removeChild uses)
before returning; keep the existing pendingInsertBefore splice and the
nodesRefRemoveChild patch emission but add the descendant-ref clearing and
instance-queueing steps so portaled unmounts properly clean up
backgroundSnapshotInstanceManager.

In `@packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts`:
- Around line 494-508: The finally currently flips back to the background thread
immediately even if componentAtIndex returns a Promise; change the logic in the
componentAtIndex call site so you call
globalThis.lynxTestingEnv.switchToMainThread(), invoke componentAtIndex(e,
$$uiSign, index, ...args) and capture the result, then if the result is a
Promise attach .finally(() => { if (isBackground)
globalThis.lynxTestingEnv.switchToBackgroundThread(); }) to restore the thread
after the async continuation; if the result is not a Promise restore immediately
as before. Ensure you reference componentAtIndex, $$uiSign, isBackground,
switchToMainThread and switchToBackgroundThread when making this change.

---

Outside diff comments:
In `@packages/testing-library/testing-environment/src/index.ts`:
- Around line 448-458: createSelectorQuery.select is eagerly querying the
document via lynxTestingEnv.env.window.document.querySelector and throwing if no
element exists, but the runtime resolves selector-based NodesRef later in
setNativeProps().exec(); remove the eager existence check and instead store the
selector string (as a NodesRef) even if querySelector returns null, so
createSelectorQuery.select returns the selector-based reference without throwing
and setNativeProps().exec() continues to resolve the selector to a LynxElement
at execution time.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b66d4281-9eb1-4c4c-8ab9-759e97f15ec9

📥 Commits

Reviewing files that changed from the base of the PR and between 3841ffe and e2c5cbd.

📒 Files selected for processing (25)
  • .changeset/feat-react-portal-patch-channel.md
  • packages/react/etc/react.api.md
  • packages/react/runtime/__test__/snapshot/debug/formatPatch.test.ts
  • packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx
  • packages/react/runtime/__test__/snapshot/utils/nativeMethod.ts
  • packages/react/runtime/lazy/compat.js
  • packages/react/runtime/lazy/react.js
  • packages/react/runtime/src/index.ts
  • packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts
  • packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatch.ts
  • packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatchApply.ts
  • packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts
  • packages/react/runtime/src/snapshot/lynx/nodesRef.ts
  • packages/react/runtime/src/snapshot/lynx/portals.ts
  • packages/react/runtime/src/snapshot/lynx/portalsPending.ts
  • packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
  • packages/react/runtime/src/snapshot/snapshot/reconstructInstanceTree.ts
  • packages/react/runtime/types/types.d.ts
  • packages/react/testing-library/src/__tests__/list.test.jsx
  • packages/react/testing-library/src/__tests__/portals.test.jsx
  • packages/react/testing-library/src/fire-event.ts
  • packages/react/types/react.docs.d.ts
  • packages/testing-library/testing-environment/etc/testing-environment.api.md
  • packages/testing-library/testing-environment/src/index.ts
  • packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts

Comment thread packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts Outdated
Comment thread packages/react/runtime/src/snapshot/lynx/nodesRef.ts
Comment thread packages/react/runtime/src/snapshot/lynx/portals.ts
Comment thread packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatchApply.ts Outdated
upupming added 4 commits May 7, 2026 17:28
The patch buffer is JSON-roundtripped between threads, which converts
the `undefined` slot emitted by `before?.__id` into `null`. The apply
side previously checked `beforeId !== undefined` and silently fell back
to `__AppendElement` when the lookup missed, which masked the wrong
ordering on `__InsertElementBefore` paths.

Switch to `beforeId != null` and assert insertion order in the prepend
test so the regression to the append fallback can't slip back in.
`applyNodesRefRemoveChild` previously detached the portaled element
and dropped its `SnapshotInstance` entries from the manager, but never
ran the recursive `unref` step that `SnapshotInstance.removeChild`
performs (snapshot.ts:431). As a result, `main-thread:ref` callbacks
on portaled subtrees never invoked the cleanup function returned at
mount time, and `WorkletRefImpl`s under the portal kept pointing at
the removed element.

Mirror the regular teardown by calling `unref(child, true)` before
`__RemoveElement`. Adds a testing-library reproducer that asserts a
`main-thread:ref` callback's returned cleanup runs on portal unmount.
Unknown `childId` in `nodesRefInsertBefore` / `nodesRefRemoveChild` is a
bg/main desync (stale background reference, double-unmount, etc.) —
surface it via `sendCtxNotFoundEventToBackground` so the background can
re-sync, mirroring the regular `InsertBefore` / `RemoveChild` ops in the
same dispatcher instead of throwing.

The `applyNodesRef*` functions now take a resolved `SnapshotInstance`,
so the dispatcher does the lookup and routing. Host-resolution failures
on insert still throw (caller-side input error, not a patch-channel
desync).
…ch-channel

# Conflicts:
#	packages/react/runtime/src/index.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e78017dc08

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 7, 2026

React Example with Element Template

#148 Bundle Size — 198.57KiB (0%).

9821d16(current) vs 0d51ee8 main#139(baseline)

Bundle metrics  Change 2 changes
                 Current
#148
     Baseline
#139
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
No change  Cache Invalidation 0% 0%
No change  Chunks 0 0
No change  Assets 4 4
Change  Modules 79(+1.28%) 78
No change  Duplicate Modules 23 23
Change  Duplicate Code 40.35%(-0.07%) 40.38%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#148
     Baseline
#139
No change  IMG 145.76KiB 145.76KiB
No change  Other 52.81KiB 52.81KiB

Bundle analysis reportBranch feat-react-portal-patch-channelProject dashboard


Generated by RelativeCIDocumentationReport issue

`applyNodesRefRemoveChild` previously only deleted SI entries from the
manager. Mirror the full teardown that `SnapshotInstance.removeChild`
runs:

- `snapshotDestroyList(v)` for `<list>` holders, otherwise native list
  callbacks + `gSignMap`/`gRecycleMap` keep stale state across portal
  mount/unmount cycles.
- Null out `__parent` / `__previousSibling` / `__nextSibling`.
- Drop `__elements` / `__element_root` refs.

Keeps the portal teardown at parity with the regular tree teardown so
future cleanup added to the regular path won't silently miss portaled
subtrees.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37cdfbc0d9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/runtime/src/snapshot/lynx/portals.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts`:
- Around line 101-103: The code is directly mutating private SnapshotInstance
fields (__parent, __previousSibling, __nextSibling) from outside the class which
breaks strict TypeScript; add a public instance method (e.g.,
SnapshotInstance.clearLinks() or SnapshotInstance.unlink()) or a module-scoped
helper inside the SnapshotInstance declaring module that sets those three
private fields to null, keep it exported/usable, and then replace the direct
assignments in nodesRefApply (the places that currently do v.__parent = null;
v.__previousSibling = null; v.__nextSibling = null;) with a call to that new
method (e.g., v.clearLinks()) so the private members are only mutated inside the
class/module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4a28c908-4f93-4b24-80e0-fa3a9b92e52d

📥 Commits

Reviewing files that changed from the base of the PR and between e78017d and 37cdfbc.

📒 Files selected for processing (1)
  • packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts

Comment thread packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts Outdated
upupming added 2 commits May 7, 2026 19:33
Reproducer for the snapshotDestroyList gap fixed in 37cdfbc: spy on
`__UpdateListCallbacks`, render a portal containing a `<list>`, unmount
it, and assert an additional `__UpdateListCallbacks` call (the cleanup
trio that snapshotDestroyList emits) fires.

Verified the test fails on the previous commit (`27da0f68`, no list
teardown) and passes on `37cdfbc0` (with the fix).
`__parent` / `__previousSibling` / `__nextSibling` are `private` on
`SnapshotInstance`; the previous commit assigned them directly which
broke `tsc --build`. Wrap in the same `as unknown as { ... }` cast that
`portals.ts` already uses for `__parent`. Local vitest didn't catch it
because vitest doesn't type-check; CI's `tsc --build` step did.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 99d9b41f0b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/runtime/src/snapshot/lynx/portals.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react/testing-library/src/__tests__/portals.test.jsx`:
- Line 4: Add a global test teardown to restore spies/mocks after each test to
prevent accumulated vi.spyOn() call history from breaking assertions that rely
on fixed call indices or counts; in the test file's top-level scope (the module
that declares the tests and uses vi.spyOn), add afterEach(() => {
vi.restoreAllMocks(); }) so spies created by vi.spyOn() are reset between tests
and deterministic assertions at locations referencing call indices/counts will
not fail.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 43726fa3-b060-466f-880c-b72270374894

📥 Commits

Reviewing files that changed from the base of the PR and between 37cdfbc and 99d9b41.

📒 Files selected for processing (2)
  • packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts
  • packages/react/testing-library/src/__tests__/portals.test.jsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts

Comment thread packages/react/testing-library/src/__tests__/portals.test.jsx
upupming added 2 commits May 7, 2026 20:00
The runtime coverage threshold is 100%. The new `snapshotDestroyList`
branch in `applyNodesRefRemoveChild` (37cdfbc) was only exercised by
the testing-library suite, which doesn't feed runtime coverage — so
test-react/Vitest runners both failed at the threshold check.

Add a runtime-level unit test that builds a list-holder SI directly
via `__SNAPSHOT__(<list>{HOLE}</list>)`, runs `nodesRefRemoveChild`,
and asserts `snapshotDestroyList`'s observable side effects (cleanup
trio replaces the real list callbacks, manager entry deleted).
`fakeRoot.removeChild` previously emitted only the main-thread
`nodesRefRemoveChild` op, leaving the portaled `BackgroundSnapshotInstance`
subtree alive in `backgroundSnapshotInstanceManager`. Repeated
mount/unmount cycles accumulated stale BSIs.

Mirror `BackgroundSnapshotInstance.removeChild`'s bookkeeping: mark
`__removed_from_tree` and push the child id into
`globalBackgroundSnapshotInstancesToRemove` so commit captures it for
the debounced 10s `tearDown` (which traverses the subtree and drops
entries from the manager).

The pre-existing e2e test asserted `bg manager size === 0` post-unmount
while importing the manager from `lib/` — a separate, stale module
copy. Switch to `src/` and account for the bg root SI (size === 1
baseline). Add a new state-change-unmount reproducer that uses fake
timers to drive the debounced cleanup.
@upupming upupming merged commit 7a15710 into main May 8, 2026
82 of 84 checks passed
@upupming upupming deleted the feat-react-portal-patch-channel branch May 8, 2026 03:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants